/*
 *  Copyright (C) 2004 Cidero, Inc.
 *
 *  Permission is hereby granted to any person obtaining a copy of 
 *  this software to use, copy, modify, merge, publish, and distribute
 *  the software for any non-commercial purpose, subject to the
 *  following conditions:
 *  
 *  The above copyright notice and this permission notice shall be included
 *  in all copies or substantial portions of the Software.
 *
 *  THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS 
 *  OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
 *  FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL 
 *  THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
 *  LIABILITY IN CONNECTION WITH THE SOFTWARE.
 * 
 *  File: $RCSfile: MediaRendererSession.java,v $
 *
 */
package com.cidero.bridge;

import java.io.BufferedInputStream;
import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.net.MalformedURLException;
import java.net.URL;
import java.net.URLConnection;
import java.nio.ByteBuffer;
import java.util.ArrayList;
import java.util.Vector;
import java.util.Collections;
import java.util.logging.Logger;
import java.text.NumberFormat;

import org.cybergarage.xml.XML;

import com.cidero.upnp.*;
import com.cidero.util.ByteBufferQueue;
import com.cidero.util.ShoutcastOutputStream;
import com.cidero.util.ShoutcastMetadata;
import com.cidero.util.NetUtil;
import com.cidero.util.MrUtil;
import com.cidero.http.*;

/**
 *  Class to hold information associated with a MediaRenderer 'session'
 *  which may consist of playback of a single file, or multiple
 *  files (playlist case)
 *
 *  A playback session may be shared by several media renderers, allowing
 *  for 'synchronized' playback.  (The degree of synchronization will
 *  probably range from 'pretty good' for devices of the same type, to
 *  'marginal' for devices with wildly different buffering strategies.
 * 
 */
public class MediaRendererSession implements Runnable
{
  private static Logger logger = Logger.getLogger("com.cidero.bridge");

  public final static int STATUS_RUNNING       = 1;
  public final static int STATUS_STOPPED       = 2;
  public final static int STATUS_END_OF_URL    = 3;
  public final static int STATUS_REPOSITIONING = 4;
  
  public  static int QUEUE_PACKET_SIZE = 4096;
  public  static int RENDERER_TIMEOUT_MILLISEC = 12000;

  int sessionState = STATUS_STOPPED;


  // Global list of active sessions. Use vector since it's synchronized
  static Vector sessionList = new Vector();
  
  //
  // URL of resource that this session is playing back. This is either
  // a URL corresponding to a single item, or a URL of a playlist.
  // This is stored in the session so other renderers can 'join' this
  // session if they play back the same resource
  // 
  String    resourceURL;      

  // Queue for data transfer between input/output HTTP connections
  ByteBufferQueue  queue;   

  // List of renderer bridges for session instance
  Vector    rendererBridgeList = new Vector();

  // List of CDS objects for this session (multiple items if playlist)
  CDSObjectList objList = new CDSObjectList();
  CDSObjectList shuffledObjList = new CDSObjectList();
  // Reference to normal or shuffled object list. Defaults to normal list
  CDSObjectList currObjList = objList;

  // Current object.  This is separate from the currObjIndex since 
  // it handles the transient one-time playback object case 
  // (NextAVTrasnportURI action), in which case the object is never 
  // put on the local playback list
  CDSObject currObj = null;
  int       currObjIndex = 0;

  // Clone of the current CDS object. This is needed so it can be
  // modified if needed.  (need to modify Artist/Title info on-the-fly
  // in the case of Internet Radio)
  CDSObject currObjClone;

  String    playSpeed = "1.0";
  long      startTimeMillis = -1;   // local system time at start of playback

  SyncShoutcastGroup syncShoutcastGroup = null;
  
  // Buffer for incoming shoutcast metadata
  byte[] metadataBuf = new byte[4096];

  // Amount of time to wait for other client to join session
  int syncWaitMillisec;  

  // Default is to always request shoutcast data. It only gets inserted
  // in proxy output stream if clients have requested it
  boolean icyMetadataRequestFlag = true; 

  // HTTP user agent for session - used to differentiate device types
  // (only devices with same userAgent value are allowed to attempt to sync)
  String    userAgent;

  /**
   * Construct a UPnP MediaRendererSession instance, specifying an initial
   * bridge device (e.g. Prismiq Bridge, Shoutcast bridge, etc...), and
   * the URL of the resource to be rendered. 
   *
   * Note that additional bridge devices may join the session later. 
   * The session is not considered completed until the rendering is
   * complete, or all 'client' renderer devices detach from the session
   *
   * 
   * @param bridge    Initial media renderer bridge device for session 
   *                
   * @param url       URL of resource for this session. This can be
   *                  the name of a single media item, or the name
   *                  of a playlist (.m3u, .pls (TODO) ). 
   * 
   * @param metaData  DIDL-Lite metadata associated with URL
   *
   * @param userAgent HTTP client user-agent header. 
   *
   * @param syncWaitMillisec  
   *
   * @throws UPnPException if there is a problem with the URL or the
   *         metadata
   *         
   */
  public MediaRendererSession( MediaRendererBridge rendererBridge,
                               String resourceURL, String metaData,
                               String userAgent,
                               int syncWaitMillisec )
    throws UPnPException
  {
    rendererBridgeList.add( rendererBridge );
    this.resourceURL = resourceURL;
    this.userAgent = userAgent;
    this.syncWaitMillisec = syncWaitMillisec;

    //
    // 128-bit audio is 16 Kbytes/sec. Allow for 4 secs worth of 
    // buffering (64 k)
    //
    queue = new ByteBufferQueue( 4096, 16 );
    
    // If resource is a single item, set the URL list for this session
    // to the resource. If it is a playlist, go get the playlist,
    // parse all the URL's in it, and add each URL to the session URL list
    if( resourceURL.endsWith(".m3u") || resourceURL.endsWith(".pls") )
    {
      logger.fine("SetAVTransportURI: Building session from playlist");
      buildSessionFromPlaylist( resourceURL, metaData );
    }
    else
    {
      logger.fine("Session consists of single URI");
      addURL( resourceURL, metaData );
    }

    // Add newly constructed session to global session list
    sessionList.add( this );
  }
  
  public void addRendererBridge( MediaRendererBridge bridge ) {
    rendererBridgeList.add( bridge );
  }

  public void removeRendererBridge( MediaRendererBridge bridge ) {
    rendererBridgeList.remove( bridge );
  }

  public String getResourceURL() {
    return resourceURL;
  }

  public String getUserAgent() {
    return userAgent;
  }

  public int getSyncWaitMillisec() {
    return syncWaitMillisec;
  }

  public ByteBufferQueue getQueue() {
    return queue;
  }

  public void setIcyMetadataRequestFlag( boolean flag ) 
  {
    icyMetadataRequestFlag = flag;
  }

  public void setPlaySpeed( String speed ) {
    this.playSpeed = speed;
  }
  public String getPlaySpeed() {
    return playSpeed;
  }
  
  /**
   * Set the current object list reference to point to the right list
   * depending on the play mode (shuffle/normal).  This routine is
   * called at the start of every play invocation, and whenever a 
   * UPnP SetPlayMode action is processed
   */
  public synchronized void setPlayMode( String playMode )
  {
    if( playMode.equals(AVTransport.PLAY_MODE_SHUFFLE) ) 
      currObjList = shuffledObjList;
    else
      currObjList = objList;
  }

  /**
   *  Clear the session so object can be reused
   */
  public void clear() 
  {
    objList.clear();
    queue.clear();
    syncShoutcastGroup = null;
  }
  
  /**
   *  Reset session - called at start of every run invocation
   */
  public void reset() 
  {
    currObjIndex = 0;
    currObj = null;

    // Create shuffled version of URL list indexes
    shuffledObjList.clear();
    shuffledObjList.addAll( objList );
    Collections.shuffle( shuffledObjList );

    // Random,shuffle,repeat modes tied to 1st bridge device associated
    // with session for now
    MediaRendererBridge bridge = 
      (MediaRendererBridge)rendererBridgeList.get(0);

    String playMode = bridge.getAVTransport().
                        getStateVariableValue("CurrentPlayMode");

    setPlayMode( playMode );
  }
  
  /**
   * Create a CDS object from a URL and some optional metadata 
   *
   * @return  CDS object version of the URL/metadata, or null if
   *          there was an error with the metadata
   *
   * @throws UPnPException if there is a problem with the URL or the 
   *         metaData
   */
  public CDSObject createObj( String url, String metaData )
    throws UPnPException
  {
    CDSObjectList tmpObjList = new CDSObjectList( metaData );

    // Expect only a single object's worth of metadata (TODO: this may be
    // the wrong assumption - check)
    if( tmpObjList.size() == 1 )
    {
      CDSObject obj = tmpObjList.getObject(0);

      // If the metadata included a valid resource, leave it alone. If
      // not, create a basic resource element from the url. 
      if( obj.getResource(0) == null )
      {
        CDSResource resource = new CDSResource( url );
        obj.addResource( resource );
      }
      return obj;
    }

    throw new UPnPException( "Error adding object - metadata described " + 
               tmpObjList.size() + " objects instead of the expected 1");
  }

  /**
   *  Add an object to the list of objects for this session.  These are the
   *  'real' MediaServer URLs being sent by the UPNP Control Point
   *  (as opposed to the local bridge-generated shoutcast URLs)
   */
  public synchronized void addURL( String url, String metaData )
    throws UPnPException
  {
    logger.fine("Adding URL " + url + " index = " + objList.size() );
    
    CDSObject obj = createObj( url, metaData );

    if( obj == null )
      return;
    
    //
    // Set the trackNumber property to the list position. Note that 
    // trackNumber is not an 'official' UPnP property (the UPnP one
    // is 'originalTrackNumber') - it's just an extra tag applied to
    // the in-memory CDSObject for convenience.
    //
    obj.setTrackNumber( objList.size()+1 );

    objList.add( obj );
  }

  /**
   *  Return the first object in the local object list (play queue).  
   */
  public synchronized CDSObject getFirstObject() 
  {
    // curr obj list is reference to normal or shuffled
    if ( currObjList.size() > 0 ) 
      currObj = currObjList.getObject( 0 );
    else
      currObj = null;

    return currObj;
  }

  public synchronized CDSObject getCurrentObject() 
  {
    if( currObjIndex >= 0 )
      currObj = currObjList.getObject( currObjIndex );
    else
      currObj = null;

    return currObj;
  }

  /**
   * Get next object to play.  If the state variable 'NextAVTransportURI' 
   * contains a valid value (interactive Jukebox-style controller
   * interaction), then it is used, and the state variable
   * is cleared. If the state variable is not set, then the next
   * item in the local URL playlist is used.
   *
   * @return  reference to next object, or null if no more
   */ 
  public synchronized CDSObject getNextObject() 
  {
    // Random,shuffle,repeat modes tied to 1st bridge device associated
    // with session
    MediaRendererBridge bridge = 
      (MediaRendererBridge)rendererBridgeList.get(0);
    AVTransport avTransport = bridge.getAVTransport();

    String playMode = avTransport.getStateVariableValue("CurrentPlayMode");
    logger.fine("moveToNextURL: PlayMode: " + playMode );

    // Repeat mode overrides everything
    if (playMode.equals(AVTransport.PLAY_MODE_REPEAT_ONE)) 
      return currObj;

    // Try using NexAVTransportURI/NexAVTransportURIMetaData
    String nextURI = null;
    try
    {
      nextURI = avTransport.getStateVariableValue("NextAVTransportURI");
      if( nextURI != null & (! nextURI.trim().equals("") ) )
      {
        String nextURIMetaData =
        avTransport.getStateVariableValue("NextAVTransportURIMetaData");
      
        logger.fine("Using NextAVTransportURI (and clearing): URI:" + 
                    nextURI + " Meta: " + nextURIMetaData );

        CDSObject obj = createObj( nextURI, nextURIMetaData );

        // Clear NextURI state variables now that they have been 'consumed'
        bridge.getAVTransport().setStateVariable( "NextAVTransportURI", "" );
        bridge.getAVTransport().setStateVariable( "NextAVTransportURIMetaData",
                                                  "" );

        // Update current AVTransport URI state variables
        bridge.getAVTransport().setStateVariable( "AVTransportURI", nextURI );
        bridge.getAVTransport().setStateVariable( "AVTransportURIMetaData",
                                                  nextURIMetaData );
        currObj = obj;
        return obj;
      }
    }
    catch( UPnPException e )
    {
      // Print out warning and continue on in this case, reverting to the 
      // local playlist
      logger.warning("Exception processing NextAVTransportURI: " + nextURI +
                     " " + e );
    }
    
    // Look forward in local playlist 
    if (playMode.equals(AVTransport.PLAY_MODE_NORMAL) )
    {
      if (currObjIndex < (currObjList.size()-1) )
        currObjIndex++;  
      else
        currObjIndex = -1;  // Done
    }
    // Note: assume continuous repeat if user requests shuffle mode
    else if (playMode.equals(AVTransport.PLAY_MODE_REPEAT_ALL) ||
             playMode.equals(AVTransport.PLAY_MODE_SHUFFLE)) 
    {
      if (currObjIndex < (currObjList.size()-1))
        currObjIndex++;  
      else
        currObjIndex = 0;  // restart from beginning
    }
    
    logger.fine("moveToNextURL: currObjIndex now: " + currObjIndex );

    if( currObjIndex >= 0 )
      currObj = currObjList.getObject( currObjIndex );
    else
      currObj = null;

    return currObj;
  }

  /**
   * Move to the previous URL in the local playlist
   * @return  true if there was a valid previous URL, false if not
   */ 
  public boolean moveToPrevURL() 
  {
    if ( currObjIndex > 0 )
      currObjIndex--;  
    else
      currObjIndex = -1;  

    if( currObjIndex >= 0 )
    {
      currObj = (CDSObject)objList.get( currObjIndex );
      return true;
    }
    else
    {
      currObj = null;
      return false;
    }
  }

  Thread sessionThread = null;

  /** 
   * Start the session running in a separate thread. If the session
   * thread is already running (more than 'client' renderer may be using
   * a session, so if each client issues a 'start', it is redundant
   */
  public void start()
  {
    logger.info("Starting playback session");

    if( sessionThread == null )
    {
      sessionThread = new Thread( this );
      sessionThread.start();  // invokes run() method
    }
    else
    {
      logger.fine("session thread already started - ignoring start req");
    }
  }
  
  public void stop()
  {
    logger.info( "Stopping playback session" );
    sessionThread = null;

    // Wait a bit
    MrUtil.sleep(1000);
  }

  public void runSyncShoutcast()
  {
    //  Wait a bit for other clients to join before proceeding.  
    if( syncWaitMillisec > 0 )
    {
      logger.info(" Delaying " + syncWaitMillisec/1000 +
                  " seconds waiting for other renderers to join session");
      //MrUtil.sleep( syncWaitMillisec - 1000 );
      MrUtil.sleep( syncWaitMillisec );
    }
    else
    {
      // Always give the session a bit of time to get going before starting
      // sync group reading the proxy queue - it's good to have some data
      // buffered up, especially when using a NAS drive (which may need
      // to be 'woken up' before it's able to feed data
      MrUtil.sleep( 3000 );
    }
    
    // The run routine returns when end of input from master server
    // detected, or if *all* clients disconnected
    logger.fine("Running sync shoutcast group ");
    syncShoutcastGroup.run();
  }
  

  /**
   * Reposition to next object in play list. If already at last item, 
   * this is a no-op. This is intended to be invoked whenever a 'Next'
   * UPnP action is received (not during normal playlist processing)
   */
  public synchronized void next()
  {
    logger.fine( "Session: next " );

    if (currObjIndex < (objList.size()-1))
    {
      currObjIndex++;
      // Set flag so playback breaks out of playback loop for current URL
      sessionState = STATUS_REPOSITIONING;
    }
  }

  /**
   * Reposition to previous object in play list. If already at last item, 
   * this is a no-op. This is intended to be invoked whenever a 'Next'
   * UPnP action is received (not during normal playlist processing)
   */
  public synchronized void prev()
  {
    logger.fine( "Session: prev " );

    if (currObjIndex > 0)
    {
      currObjIndex--;
      // Set flag so playback breaks out of playback loop for current URL
      sessionState = STATUS_REPOSITIONING;
    }
  }
  
  /**
   *  Run a single UPNP rendering 'session'. Each session can consist 
   *  of a single media file playback, or multiple file playback. The 
   *  fact that there are multiple files is transparent to the dumb 
   *  media device, since it just sees a (more or less) continuous stream.
   *  The exception to this is if the format of the media changes from
   *  song to song - some devices will no doubt hava problems with
   *  that, so the connection to the dumb device may be restarted in
   *  that case...
   */
  public void run()
  {
    boolean newDataType = true;
    
    String hostAddr = NetUtil.getDefaultLocalIPAddress();
    
    logger.fine( "Session: running - opening queue " );


    // Reset to beginning of session at start of every play invocation
    // (A session may be run multiple times if the play/stop sequence
    // is used repeatedly).  Also create a shuffled version of the objList
    // for use when the user selects shuffle mode
    reset();
    
    queue.open();   // sets eof state to false, clears queue

    CDSObject obj = getFirstObject();

    while( (sessionThread != null) && (obj != null ) )  
    {
      // Get URL for 1st and only resource in object (Media Server may serve
      // up multiple resources for same content, but only one of them
      // gets communicated to the renderer)
      CDSResource resource = obj.getResource(0);
      String url = resource.getName();
      logger.fine( "Session: processing URL " + url );

      //
      // Update state variables for all bridged renderer devices attached
      // to this session. If this is the first time through, or the data
      // type (MIME type) has changed (NOT YET IMPLEMENTED), issue a 
      // play command to all devices.
      //
      try 
      {
        for( int n = 0 ; n < rendererBridgeList.size() ; n++ )
        {
          MediaRendererBridge renderer;
          renderer = (MediaRendererBridge)rendererBridgeList.get(n);

          AVTransport avTransport = renderer.getAVTransport();

          //
          // State variable updates
          //
          avTransport.setStateVariable("CurrentTrack",
                               Integer.toString( obj.getTrackNumber() ) );
          String trackDuration = resource.getDuration();
          if( trackDuration == null )
            avTransport.setStateVariable("CurrentTrackDuration", "00:00:00" );
          else
            avTransport.setStateVariable("CurrentTrackDuration",
                                         trackDuration );
          avTransport.setStateVariable("CurrentTrackURI", url );

          // Send key metadata elements - don't send resource in this case
          avTransport.setStateVariable("CurrentTrackMetaData",
                        CDS.toDIDL( obj, "dc:title,dc:creator,upnp:artist" ));
          
          // Create a clone of the now-playing CDS object so it can be
          // modified if needed.  (need to modify Artist/Title info on-the-fly
          // in the case of Internet Radio)
          currObjClone = (CDSObject)obj.clone();
          
          //
          // Issue device play commands
          //
          if( newDataType )
          {
            renderer.avTransportSetTransportURI(
                "http://" + hostAddr + ":8081" + renderer.getProxyUrlPath() );

            // returns immediately after starting playback...
            renderer.avTransportPlay( playSpeed );  
          }
        }
        newDataType = false;  // for now  TODO check data type for change
      }
      catch( MediaRendererException e )
      {
        logger.fine( "Exception starting playback device" + e );
      }
        
      logger.fine( "Session: after invoking play for device" );

      // 
      // Read URL and pass it to queue.  If there was a problem, break out
      // of session
      //
      try
      {
        int state = readAndQueueURL( url, obj );

        logger.fine(" state after readAndQueueURL = " + state +
                    " currObjIndex index = " + currObjIndex );
        
        if( state == STATUS_STOPPED )
        {
          break;
        }
        else if( state == STATUS_REPOSITIONING )   
        {
          //
          // If user requested a Next, Prev action, stop all renderers and
          // restart them (necessary to flush their streaming input buffers)
          //
          queue.clear();
    
          // restart all renderer devices
          stopAllRenderers();

          syncShoutcastGroup.stop();  
          MrUtil.sleep(1000);
          syncShoutcastGroup = null;

          try 
          {
            for( int n = 0 ; n < rendererBridgeList.size() ; n++ )
            {
              MediaRendererBridge renderer;
              renderer = (MediaRendererBridge)rendererBridgeList.get(n);
              renderer.avTransportPlay( playSpeed );  
            }
          }
          catch( MediaRendererException e )
          {
            logger.fine( "Exception starting playback device" + e );
          }
          
          // repositioning of index already done in prev, next methods.
          // Just need to get object for current index
          obj = getCurrentObject();

          // Don't need to do anything here
        }
        else if( state == STATUS_END_OF_URL )   
        {
          //
          // Move to next URL. If repeat mode, this may point to the 
          // same one, but more often it will point to next URL in a
          // playlist.
          //
          logger.fine("Session: moving to next URL");
          obj = getNextObject();
        }
      }
      catch( MalformedURLException e )
      {
        logger.fine( "Exception reading URL" + e );
        break;
      }
      
    } // while( (sessionThread != null) && (obj != null ) )  


    queue.close();  // sends 'EOF' state to queue reader

    //
    // If session was forcibly shutdown, immediately stop the rendering
    // device, otherwise wait for the device to drain it's buffer
    //
    if( sessionThread == null )  
    {
      logger.fine("Session: stopped by user - stopping renderer(s) ");

      stopAllRenderers();
      
      if( sessionList.remove( this ) )
        logger.fine("removed session from global session list");
      else
        logger.warning("Error removing session from global session list");

      logger.fine("Session: Done - terminating thread");

      return;
    }
    
    logger.fine("Session: no more URL's to process - wait for drain");

    // TODO: Need to think of common way to do this for 
    // Prismiq/Shoutcast/whatever. For now just wait 20 sec
    //
    // Wait for stream state to change to STREAM_STOPPED (prismiq has
    // then drained all the data in it's input buffer, so all the media
    // sent to it has actually been played). Timeout after 20 seconds
    //

    int waitSecRemaining = 20;
    while( waitSecRemaining > 0 )
    {
      MrUtil.sleep(1000);
      logger.fine("Session: waiting for drain " + waitSecRemaining );
      waitSecRemaining--;
    }

    logger.fine("Session: drain complete - stopping renderers" );
    stopAllRenderers();

    /*  Old
    while( waitSecCount < 40 )
    {
      if( mediaRenderer.getStreamState() != AbstractMediaRenderer.STREAM_RUNNING )
      {
        logger.fine("Session: MediaRenderer device reported STREAM_STOPPED");

        mediaRenderer.stop();

        // Need to let UPNP AVTransport service know about the change
        bridge.getAVTransport().updateStateVariable("TransportState", 
                                                   AVTransport.STATE_STOPPED );
        break;
      }
      
      logger.fine("Session: Waiting for client to empty stream");

      try { Thread.sleep(1000); } catch( InterruptedException e ) { }
      
      waitSecCount++;
    }

    if( waitSecCount >= 40 )
      logger.fine("Session: Timeout waiting for device empty feedback");
    */
 
    // Remove session from global session list
    logger.fine("removing session from global session list - before count: " +
                sessionList.size() );

    if( ! sessionList.remove( this ) )
      logger.warning("Error removing session from global session list");
      
    logger.fine("removing session from global session list - after count: " +
                sessionList.size() );

    logger.fine("Session: Done - terminating thread");
    sessionThread = null;
  }

  /**
   * Update the Artist/Title metadata for the UPnP AVTransport instance
   * using the metadata embedded in the shoutcast stream.
   *
   * Shoutcast metadata typically looks like:
   *    StreamTitle='U2 - Gloria';StreamURL='www.radioparadise.com';
   *
   */
  public void updateCurrentTrackMetadata( String metadataString )
  {
    // Instantiate a metadata object (parses the string)
    ShoutcastMetadata shoutMetadata = new ShoutcastMetadata( metadataString );

    currObjClone.setTitle( shoutMetadata.getTitle() );
    // If object has an 'artist' field use it, otherwise use 'creator' field
    if( currObjClone instanceof CDSMusicTrack )
      ((CDSMusicTrack)currObjClone).setArtist( shoutMetadata.getArtist() );
    else
      currObjClone.setCreator( shoutMetadata.getArtist() );

    for( int n = 0 ; n < rendererBridgeList.size() ; n++ )
    {
      MediaRendererBridge renderer;
      renderer = (MediaRendererBridge)rendererBridgeList.get(n);
      AVTransport avTransport = renderer.getAVTransport();

      // Send key metadata elements - don't send resource in this case
      avTransport.setStateVariable("CurrentTrackMetaData",
             CDS.toDIDL( currObjClone, "dc:title,dc:creator,upnp:artist" ) );
    }
  }


  /**
   * Stop all renderers associated with session
   */
  public void stopAllRenderers()
  {
    for( int n = 0 ; n < rendererBridgeList.size() ; n++ )
    {
      MediaRendererBridge renderer = 
        (MediaRendererBridge)rendererBridgeList.get(n);

      try 
      {
        renderer.avTransportStop();
      }
      catch( MediaRendererException e )
      {
        logger.warning( "stopAllRenderers: stop exception! " + e );        
        // Continue on and try to stop other devices
      }
    }
  }

  /**
   * If session resource is a playlist, go get the playlist,
   * parse all the URL's in it, and add each URL to the session URL list
   */
  public void buildSessionFromPlaylist( String playlistURI, String metaData )
    throws UPnPException
  {
    try 
    {
      URL playlistURL = new URL( playlistURI );

      logger.fine( "BuildSessionFromPlaylist: Opening connection to: " +
                          playlistURL + " MetaData: " + metaData );
      URLConnection conn = playlistURL.openConnection();
      
      // Set any header properties
      //conn.setRequestProperty( "Cookie", "cookieName=cookieValue" );
      
      // Send the request to the server (GET Request if URL is HTTP)
      logger.fine( "  Connecting" );
      conn.connect();
      
      logger.finest( "  ---HTTP Response Headers:----" );

      // List all the response headers from server
      /*
      for( int n = 0 ;; n++ )
      {
        String headerName = conn.getHeaderFieldKey( n );
        String headerValue = conn.getHeaderField( n );
        
        if( (headerName == null) && (headerValue == null) )
          break;  // No more headers

        if( headerName == null )
        {
          logger.finest("  Server HTTP version is: " + headerValue );
        }
        else
        {
          logger.finest( headerName + ": " + headerValue );
        }
      }
      */

			M3UPlaylist m3uPlaylist = new M3UPlaylist( conn.getInputStream() );
			
      // Save reference to list of CDS objects 
      objList = m3uPlaylist.getObjectList();

      // If playlist didn't have 'creator' (artist) data (some playlists
      // only have the title), but playlist 'parent' metadata *was* 
      // passed via the AVSetURI action, copy the 'creator' attribute
      // over to each playlist item

      // Expect only a single object's worth of metadata (TODO: this may be
      // the wrong assumption - check)
      CDSObjectList tmpObjList = new CDSObjectList( metaData );
      if( tmpObjList.size() == 1 )
      {
        CDSObject parentObj = tmpObjList.getObject(0);

        for( int n = 0 ; n < objList.size() ; n++ )
        {
          CDSObject obj = objList.getObject(n);
          if( (obj.getCreator() == null) || obj.getCreator().equals("") )
          {
            // Prefer Artist property if container is musicalbum - 
            // backup is creator
            String creator = null;
            if( parentObj instanceof CDSMusicAlbum  )
              creator = ((CDSMusicAlbum)parentObj).getArtist();

            if( creator == null )
              creator = parentObj.getCreator();

            logger.fine("Setting creator for playlist obj to: " +
                        creator );
                               
            obj.setCreator( creator );
          }
          else
          {
            logger.fine("Not overriding creator for playlist obj of: " +
                        obj.getCreator() );
          }
        }
      }
    }
    catch( MalformedURLException e )
    {
      throw new UPnPException( "Malformed Playlist URL: " + playlistURI );
    }
    catch( PlaylistException e )
    {
      throw new UPnPException( "Playlist Error: " + playlistURI );
    }
		catch( Exception e )
    {
      throw new UPnPException("readURL: Exception" + e );
		}
  }

  int icy_metaint;
  int nonMetadataBytesRemaining;
  long lastMetadataUpdateTimeMillis;
  String metadataString;

  /** 
   * Read data from MediaServer URL and pass it to queue.  This a basically
   * an HTTP proxy.  The HTTP server exposed to the MediaRenderer devices
   * reads it's data from the other end of the queue
   *
   * @return sessionState   STATUS_STOPPED, STATUS_REPOSITIONING,
   *                        STATUS_END_OF_URL
   *
   */
  public int readAndQueueURL( String urlString, CDSObject obj )
    throws MalformedURLException
  {
    logger.fine( "readAndQueueURL " + urlString + "\n\n");

    sessionState = STATUS_RUNNING;

    URL url = new URL( urlString );

    // Sample start time here for safety in case connection exception 
    // occurs (it's sampled again in the loop once things get going)  
    startTimeMillis = System.currentTimeMillis();
      
    try 
    {
      logger.fine( "Session: Opening connection to: " + url );

      HTTPConnection conn = new HTTPConnection();
      HTTPRequest request = new HTTPRequest( HTTP.GET, url );
      
      //request.addHeader("Host", url.getHost() );
      // Some shoutcast servers are picky about user-agents they
      // accept connections from.  Use 'Winamp' as opposed to 'Cidero'
      // here to make things work in most casees
      //request.addHeader("User-Agent", "CideroHTTP/1.0" );
      request.addHeader("User-Agent", "Winamp/1.0" );
      request.addHeader("Accept", "*/*" );
      // Use non-pipelined connection for now (HTTP/1.1 defaults to pipelined)
      request.addHeader("Connection", "close" );
      
      // Shouldn't hurt to request shoutcast metadata for non-shoutcast streams
      if( icyMetadataRequestFlag )
        request.addHeader( "Icy-Metadata", "1" );
      
      // Send the request to the server (GET Request if URL is HTTP)
      logger.fine( "Session: Connecting" );
      HTTPResponse response = conn.sendRequest( request, false );
      
      logger.fine( "Session: HTTP Response Headers from server:----" );
      logger.fine( "FirstLine: " + response.getFirstLine() );

      // List all the response headers from server
      StringBuffer hdrBuf = new StringBuffer();
      for( int n = 0 ; n < response.getNumHeaders() ; n++ )
        hdrBuf.append( response.getHeader(n).toString() + "\n" );
      logger.fine( hdrBuf.toString() );      

      icy_metaint = 0;
      String icy_metaint_value = response.getHeaderValue("icy-metaint");
      if( icy_metaint_value != null )
        icy_metaint = Integer.parseInt(icy_metaint_value);
      if( icy_metaint == 0 )
        icy_metaint = -1;  // for benefit of downstream logic

      metadataString = null;

      logger.finer("icy-metaint after parse = " + icy_metaint );

      if( response.getStatusCode() != HTTPStatus.OK )
      {
        logger.warning("Error from HTTP-GET for url " + urlString );
        logger.warning("HTTPRequest was: " + request.toString() );
        sessionState = STATUS_STOPPED;
        return sessionState; 
      }
      
      //
      // Read data until connection is closed by server. Read data in 
      // blocks of QUEUE_PACKET_SIZE bytes
      //
      long contentBytes = 0;
      long last = 0;
      int  bytesRead;
      byte[] buf = new byte[16384];
      ByteBuffer qBuf;
      
      BufferedInputStream inStream = 
        new BufferedInputStream( response.getInputStream() );
      
      nonMetadataBytesRemaining = icy_metaint;

      while( sessionThread != null ) 
      {
        // Check for repositioning (control point issued Next/Prev UPnP action)
        if (sessionState != STATUS_RUNNING) 
          return sessionState;

        //
        // Always read fixed-size chunks from source before putting in
        // queue. This makes it easier to do other things downstream (such
        // as inserting shoutcast data)
        //
        //System.out.println("Reading " + QUEUE_PACKET_SIZE + " bytes" );
        bytesRead = shoutcastStreamRead( inStream, buf, QUEUE_PACKET_SIZE );
        //System.out.println("Read " + bytesRead + " bytes" );
        
        //
        // Write the data to the queue.
        // TODO: this could be done without an intermediate buf - use
        // something like gBuf.getBuf() in read above (OJN)
        //
        if( bytesRead > 0 )
        {
          int waitMillisec = 0;
          
          while( (qBuf = queue.alloc( 2000 )) == null )  // Timeout in 2 sec
          {
            if( sessionThread == null )
              break;

            logger.fine("Session: Timeout writing to queue");

            //
            // Keep track of total wait time and shut session down if
            // wait time exceeds threshold (nominally 12 sec). Don't want
            // to block Internet feed and waste resources on their end
            //
            waitMillisec += 2000;
            if( waitMillisec > RENDERER_TIMEOUT_MILLISEC )  // Nom 12 sec
            {
              logger.warning("Renderer timeout - closing proxy session");
              sessionThread = null;
              break;
            }
          }

          if( sessionThread == null )
            break;

          qBuf.put( buf, 0, bytesRead );  // Copy data to queue buffer
          queue.put( qBuf );
        }

        if( (bytesRead < 0) || (bytesRead < QUEUE_PACKET_SIZE) )
        {
          logger.fine("Session: bytesRead = " + bytesRead + " DONE..." );
          sessionState = STATUS_END_OF_URL;
          return sessionState;
        }
        
        contentBytes += bytesRead;

        //
        // If not a shoutcast input stream, create shoutcast metadata for
        // the output side from the CDS object
        //
        if( icy_metaint <= 0 )
        {
          if( (contentBytes - last) > 128*1024 )
          {
            if( obj instanceof CDSMusicTrack )
            {
              String simShoutMetadata = "StreamTitle='" + obj.getArtist() +
                                        " - " + obj.getTitle() + "';";
              
              syncShoutcastGroup.processMetadata( simShoutMetadata );
            }
            last = contentBytes;
            logger.fine("Session: inserting obj metadata at contentBytes = "
                        + contentBytes );
          }
        }

      } // while( sessionThread != null )

      logger.fine("Session: Total contentBytes = " + contentBytes );
      sessionState = STATUS_STOPPED;
    }
		catch( Exception e )
    {
      logger.warning("Session: readURL: Exception" + e );
      sessionState = STATUS_STOPPED;
		}

    //logger.fine("Session: return 2" );
    return sessionState;
  }


  public int shoutcastStreamRead( BufferedInputStream inStream,
                                  byte[] buf, int bytes )
  {
    int bytesRemaining = bytes;
    int bytesRead;

    //System.out.println("Reading " + bytes + " bytes" );
    
    try
    {
      while( bytesRemaining > 0 )
      {
        // Only read up to point where metadata exists
        int byteRequest = bytesRemaining;
        if( (nonMetadataBytesRemaining >= 0) && 
            (byteRequest > nonMetadataBytesRemaining) )
        {
          byteRequest = nonMetadataBytesRemaining;
        }
        
        while( byteRequest > 0 )
        {
          bytesRead = inStream.read( buf, bytes-bytesRemaining, byteRequest );
          if( bytesRead > 0 )
          {
            // Adjust for next call
            byteRequest -= bytesRead;
            bytesRemaining -= bytesRead;
            if( nonMetadataBytesRemaining >= 0 )
              nonMetadataBytesRemaining -= bytesRead; 
          }
          else if( bytesRead <= 0 )
          {
            return bytes-bytesRemaining;   // early return - error?
          }
        }
        
        if( bytesRemaining > 0 )
        {
          // Read shoutcast metadata
          int metadataBytes = inStream.read() * 16;
          if( metadataBytes < 0 )
            return (bytes-bytesRemaining);   // early return - error?

          logger.finest("--metadata bytes = " + metadataBytes );

          long currTime = System.currentTimeMillis();

          if( metadataBytes > 0 )
          {
            int metadataBytesRemaining = metadataBytes;

            while( metadataBytesRemaining > 0 )
            {
              bytesRead = inStream.read( metadataBuf,
                                         metadataBytes-metadataBytesRemaining,
                                         metadataBytesRemaining );
              if( bytesRead < 0 )
              {
                logger.warning("Error reading metadata block");
                return bytes-bytesRemaining;   // returns bytes read so far
              }
              
              metadataBytesRemaining -= bytesRead;
            }

            metadataString = new String( metadataBuf, 0, metadataBytes );
            logger.fine("Found metadata: " + metadataString );
            
            syncShoutcastGroup.processMetadata( metadataString );
            lastMetadataUpdateTimeMillis = currTime;

            // Update UPnP track metadata state variable with Artist/Title
            updateCurrentTrackMetadata( metadataString );
          }
          else if( metadataString != null ) 
          {
            // Force update every 20 sec if server isn't updating it
            long timeSinceLastMetadataUpdate = 
              currTime - lastMetadataUpdateTimeMillis;
            if( timeSinceLastMetadataUpdate > 20000 )  // TODO: pref
            {
              syncShoutcastGroup.processMetadata( metadataString );
              lastMetadataUpdateTimeMillis = currTime;
            }
          }
          
          // Done - reset counter to next metadata block
          nonMetadataBytesRemaining = icy_metaint;
        }

      } // while( bytesRemaining > 0 )
    }
    catch( Exception e )
    {
      logger.warning("Session: Exception" + e );
    }

    return bytes-bytesRemaining;
  }


  /**
   * Get relative time of playback.
   *
   * @return  Elapsed time since start of playback, in HH:MM:SS format
   */

  private NumberFormat nf2 = null;

  public String getRelTime()
  {
    if( nf2 == null )
    {
      nf2 = NumberFormat.getInstance();
      nf2.setMinimumIntegerDigits(2);
      nf2.setGroupingUsed(false);
    }
    
    if( startTimeMillis < 0 )  // Session not yet started
      return "00:00:00";
    
    long timeDiffSec = (System.currentTimeMillis() - startTimeMillis)/1000;
    
    int hour = (int)(timeDiffSec/3600);
    int minute = (int)((timeDiffSec % 3600)/60);
    int sec = (int)timeDiffSec % 60;
    
    StringBuffer buf = new StringBuffer();
    buf.append( nf2.format(hour) );
    buf.append( ":" );
    buf.append( nf2.format(minute) );
    buf.append( ":" );
    buf.append( nf2.format(sec) );

    return buf.toString();
  }


  /**
   *  Find a session with a given resourceURL
   *
   *  @param   resourceURL
   *
   *           Name of the resource on the UPnP server
   *
   *  @return  session or null if no session with specified URL pair exists
   */
  public static MediaRendererSession findSession( String resourceURL )
  {
    logger.fine("Searching for session with resource " + 
                resourceURL );

    MediaRendererSession session = null;

    for( int n = 0 ; n < sessionList.size() ; n++ )
    {
      session = (MediaRendererSession)sessionList.get(n);

      if( session.getResourceURL().equals( resourceURL ) )
        return session;
    }
    
    logger.fine("No matching session found");

    return null;
  }

  /**
   *  Find a session with a given resourceURL and proxy URL (the proxyURL
   *  is the one that is exposed to the 'dumb' media renderer)
   *
   *  @param   resourceURL
   *
   *           Name of the resource on the UPnP server
   *
   *  @param   rendererProxyUrlPath  
   *
   *           Proxy Url path used by renderer
   *
   *  @return  session or null if no session with specified URL pair exists
   */
  public static MediaRendererSession 
    findSession( String resourceURL, String rendererProxyUrlPath )
  {
    logger.fine("Searching for session with resource " + 
                resourceURL + " and proxyURL " + rendererProxyUrlPath );

    MediaRendererSession session = null;

    for( int n = 0 ; n < sessionList.size() ; n++ )
    {
      session = (MediaRendererSession)sessionList.get(n);

      if( session.getResourceURL().equals( resourceURL ) )
      {
        // Check proxyURL of all associated renderers
        MediaRendererBridge renderer = 
          session.findRendererBridge( rendererProxyUrlPath );

        if( renderer != null )
          return session;
      }
    }
    
    logger.fine("No matching session found");

    return null;
  }

  public static MediaRendererSession 
    findSessionByProxyUrlPath( String rendererProxyUrlPath )
  {
    logger.fine("Searching for session with proxyUrlPath " + 
                rendererProxyUrlPath );

    MediaRendererSession session = null;

    for( int n = 0 ; n < sessionList.size() ; n++ )
    {
      session = (MediaRendererSession)sessionList.get(n);

      // Check proxyURL of all associated renderers
      MediaRendererBridge renderer = 
        session.findRendererBridge( rendererProxyUrlPath );

      if( renderer != null )
        return session;
    }
    
    logger.fine("No matching session found");

    return null;
  }

  /**
   *  Find the renderer within the session with the specified proxyURL
   *
   *  @return  renderer instance, or null if no match
   */
  public MediaRendererBridge findRendererBridge( String proxyURLPath )
  {
    for( int n = 0 ; n < rendererBridgeList.size() ; n++ )
    {
      MediaRendererBridge rendererBridge = 
        (MediaRendererBridge)rendererBridgeList.get(n);
      
      if( rendererBridge.getProxyUrlPath().equals( proxyURLPath ) )
        return rendererBridge;
    }
    
    logger.fine("No renderer with proxyURLPath " + proxyURLPath + " found" );

    return null;
  }
  
  /*
  public void setSyncShoutcastGroup( SyncShoutcastGroup syncShoutcastGroup ) 
  {
    this.syncShoutcastGroup = syncShoutcastGroup;
  }

  public SyncShoutcastGroup getSyncShoutcastGroup()
  {
    return syncShoutcastGroup;
  }
  */

  /**
   *  Join the SynchronizedShoutcastGroup associated with this session.
   *  If no group is active, create one
   *
   *  @return  Reference to new SyncShoutcastGroup, or null if joined 
   *           existing group
   */ 
  public synchronized SyncShoutcastGroup 
                      joinSyncShoutcastGroup( ShoutcastOutputStream outStream,
                                              HTTPConnection connection )
  {
    if( syncShoutcastGroup == null )  // First requester for this URL ?
    {
      String userAgent = 
        connection.getRequest().getHeaderValue(HTTP.USER_AGENT);
      //System.out.println("join - userAgent = " + userAgent );
      //AppPreferences pref = RadioServer.getPreferences();

      syncShoutcastGroup = new SyncShoutcastGroup( this, outStream,
                                                   connection );
      return syncShoutcastGroup;
    }
    else
    {
      syncShoutcastGroup.addStream( outStream, connection );

      // Return null to let caller know this thread was 'joined' with 
      // existing shoutcast group, and the thread should be terminated without
      // closing the HTTP socket session
      return null;  
    }
  }


  /**
   *  Find the most recently added session
   */
  /*
  public static MediaRendererSession findLastSession()
  {
    int lastSessionIndex = sessionList.size() - 1;

    if( lastSessionIndex < 0 )
      return null;

    return (MediaRendererSession) sessionList.get( lastSessionIndex );
  }
  */

}
